package jmine.tec.persist.schema.impl.elements;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;

import jmine.tec.persist.schema.api.AbstractProcessingSchemaElement;
import jmine.tec.persist.schema.api.ProcessingSchemaElement;
import jmine.tec.persist.schema.api.SchemaElement;
import jmine.tec.persist.schema.api.SqlDialect;

import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.builder.CompareToBuilder;

/**
 * SchemaElement representando uma constraint de chave estrangeira (foreign key)
 * 
 * @author lundberg
 */
public class ForeignKeyElement extends AbstractProcessingSchemaElement<TableElement> implements Comparable<ForeignKeyElement> {

    // regex que identifica nomes de FK's geradas pelo Hibernate
    private static final Pattern GENERATED = Pattern.compile("FK\\p{XDigit}+", Pattern.CASE_INSENSITIVE);

    private static final String GENERATED_SUFFIX_REGEX = "\\p{XDigit}{8}$";

    private static final String REFERENCING_PLACEHOLDER = "\\$referencing";

    private static final String REFERENCED_PLACEHOLDER = "\\$referenced";

    private final String referencing;

    private final String referenced;

    private final String[] referencingColumns;

    private String[] referencedColumns;

    private String name;

    private boolean onDeleteCascade;

    /**
     * Construtor
     * 
     * @param referencing nome da tabela que referencia
     * @param referenced nome da tabela referenciada
     * @param referencingColumns nomes das colunas que referenciam
     * @param referencedColumns nomes das colunas referenciadas
     * @param name nome da constraint
     * @param onDeleteCascade <code>true</code> se deve apagar filhos ao apagar o pai, <code>false</code> caso contrario.
     * @param parent mensagens do warnngs
     */
    public ForeignKeyElement(String referencing, String referenced, String[] referencingColumns, String[] referencedColumns, String name,
            ProcessingSchemaElement<?> parent, boolean onDeleteCascade) {
        super(parent);
        this.referencing = referencing;
        this.referenced = referenced;
        this.referencingColumns = referencingColumns;
        this.referencedColumns = referencedColumns;
        this.name = name;
        this.onDeleteCascade = onDeleteCascade;
    }

    /**
     * {@inheritDoc}
     */
    public List<String> createStatements(SqlDialect dialect) {
        return dialect.renderForeignKey(this.name, this.referencing, this.referenced, this.referencingColumns, this.referencedColumns,
                this.onDeleteCascade);
    }

    /**
     * {@inheritDoc}
     */
    public List<SchemaElement> getSubElements() {
        return Collections.emptyList();
    }

    /**
     * @return nome da tabela que referencia
     */
    public String getReferencing() {
        return this.referencing;
    }

    /**
     * @return nomes das colunas que referenciam
     */
    public String[] getReferencingColumns() {
        return this.referencingColumns;
    }

    /**
     * @return nomes das colunas referenciadas
     */
    public String[] getReferencedColumns() {
        return this.referencedColumns;
    }

    /**
     * @return nome da tabela referenciada
     */
    public String getReferenced() {
        return this.referenced;
    }

    /**
     * {@inheritDoc}
     */
    public int compareTo(ForeignKeyElement o) {
        CompareToBuilder builder = new CompareToBuilder();
        builder.append(this.referencing, o.referencing);
        builder.append(this.name, o.name);
        return builder.toComparison();
    }

    /**
     * Define explicitamente as colunas referenciadas, caso estas tenham sido deixadas implícitas pelo schema gerado pelo hibernate.
     * 
     * @param referencingTable tabela que possui a fk, que referencia
     */
    public void process(TableElement referencingTable) {
        if (this.referencedColumns == null) {
            TableElement referencedTable = referencingTable.getSchema().findTable(this.referenced);
            this.referencedColumns = referencedTable.getPrimaryKey().getColumns();
        }
    }

    /**
     * Segunda etapa do processamento da FK, deve ser executado após o processamento de todas as tabelas.<br/>
     * Gera um nome mais apropriado para a constraint, caso o hibernate tenha sido o responsável pela geração.
     * 
     * @param referencingTable referencingTable
     */
    public void processSecondPass(TableElement referencingTable) {
        if (this.hasRawName()) {
            this.createNameBasedOnAliases(referencingTable);
        } else {
            this.validateProvidedName(referencingTable);
        }
        this.createForeignKeyIndexIfNecessary(referencingTable);
    }

    /**
     * Verifica se a Foreign Key possui um índice correspondente. Esse índice é fundamental, pois sem ele um DELETE na tabela referenciada
     * implica em um table lock na tabela que referencia, o que gera dead locks de difícil detecção. Caso não exista, gera o índice.
     * 
     * @param referencingTable referencingTable
     */
    private void createForeignKeyIndexIfNecessary(TableElement referencingTable) {
        if (this.shouldCreateForeignKeyIndex(referencingTable)) {
            IndexElement index = new IndexElement(referencingTable.getName(), null, referencingTable);
            for (String column : this.referencingColumns) {
                index.addColumn(column);
            }
            referencingTable.addIndex(index);
        }
    }

    /**
     * Verifica se é necessário criar o índice para a fk
     * 
     * @param referencingTable referencingTable
     * @return boolean
     */
    private boolean shouldCreateForeignKeyIndex(TableElement referencingTable) {
        List<String> columnsList = Arrays.asList(this.referencingColumns);
        for (IndexElement index : referencingTable.getIndexes()) {
            if (index.containsAllColumns(columnsList)) {
                return false;
            }
        }
        for (AlternateKeyElement ak : referencingTable.getAlternateKeys()) {
            if (ak.getColumns().containsAll(columnsList)) {
                return false;
            }
        }
        if (referencingTable.getPrimaryKey() != null
                && Arrays.asList(referencingTable.getPrimaryKey().getColumns()).containsAll(columnsList)) {
            return false;
        }
        return true;
    }

    /**
     * Cria um nome para a foreign key baseado nos aliases das tabelas envolvidas
     * 
     * @param referencingTable referencingTable
     */
    private void createNameBasedOnAliases(TableElement referencingTable) {
        TableElement referencedTable = referencingTable.getSchema().findTable(this.referenced);
        String referencingAlias = referencingTable.getAlias();
        String referencedAlias = referencedTable.getAlias();
        Set<String> existing = new HashSet<String>();
        for (ForeignKeyElement fk : referencingTable.getForeignKeys()) {
            existing.add(fk.name);
        }
        if (!existing.contains(String.format("FK_%s_%s", referencedAlias, referencingAlias))) {
            this.name = String.format("FK_%s_%s", referencedAlias, referencingAlias);
        } else {
            int i = 1;
            while (existing.contains(String.format("FK_%s_%s_%s", referencedAlias, referencingAlias, i))) {
                i++;
            }
            this.name = String.format("FK_%s_%s_%s", referencedAlias, referencingAlias, i);
            this.warn(String.format(
                    "Gerado sequencial automático para a FOREIGN KEY %s na tabela_referencia %s. Coluna(s) afetada(s) '%s' "
                            + "O nome deve ser definido manualmente.", this.name, referencingTable.getName(),
                    StringUtils.join(this.referencingColumns, "; ")));
        }
    }

    /**
     * Valida que o nome fornecido para a foreign key está de acordo com os padrões estabelecidos.
     * 
     * @param referencingTable referencingTable
     */
    private void validateProvidedName(TableElement referencingTable) {
        TableElement referencedTable = referencingTable.getSchema().findTable(this.referenced);
        String referencingAlias = referencingTable.getAlias();
        String referencedAlias = referencedTable.getAlias();

        this.name = this.name.replaceAll(GENERATED_SUFFIX_REGEX, "");
        this.name = this.name.replaceAll(REFERENCING_PLACEHOLDER, referencingAlias);
        this.name = this.name.replaceAll(REFERENCED_PLACEHOLDER, referencedAlias);

        if (!this.name.startsWith(String.format("FK_%s_%s", referencedAlias, referencingAlias))) {
            this.warn(String.format("Nome de FOREIGN KEY '%s' definido fora do padrão na "
                    + "tabela '%s', o correto é 'FK_%s_%s'.Colunas referentes (%s).", this.name, this.referencing, referencedAlias,
                    referencingAlias, StringUtils.join(this.referencedColumns, "; ")));
        }
    }

    /**
     * Verifica se o nome da fk foi gerado pelo Hibernate
     * 
     * @return boolean
     */
    public boolean hasRawName() {
        return GENERATED.matcher(this.name).matches();
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String toString() {
        return String.format("FK %s %s REFERENCES %s %s", this.referencing, Arrays.toString(this.referencingColumns), this.referenced,
                Arrays.toString(this.referencedColumns));
    }

    /**
     * @return the name
     */
    public String getName() {
        return this.name;
    }

    /**
     * Verifica se esta fk aponta para o mesmo alvo que a fk dada.
     * 
     * @param fk fk
     * @return boolean
     */
    public boolean hasSameTarget(ForeignKeyElement fk) {
        return this.referenced.equals(fk.referenced) && Arrays.equals(this.referencedColumns, fk.referencedColumns);
    }
}
